NAPM 是网易的应用性能管理平台,采用非侵入的方式获取应用性能数据,可以实时展示多个维度的分析结果。本文主要给大家分享一下Android端SDK的实现原理。

前言

APM(Application Performance Management),应用性能管理,主要是为了解决应用上线之后,性能问题难以发现、难以定位的问题,通过接入APM,可以实时了解应用在运行过程中的性能表现,快速定位和修复问题。

目前国内外有不少的应用性能管理平台,例如国外的 New Relic、AppDynamics,国内的听云、OneAPM,国内各大公司也都有自己的性能监控体系。

我们也开发了自己的平台 NAPM 供公司内部的产品使用,移动端目前主要采集了网络性能、交互性能和数据(数据库、JSON、Image)处理性能数据,网络性能目前主要采集了Http请求过程中的一些性能指标,比如响应时间、首包时间、DNS时间等,同时再结合机型、版本、地理位置、运营商、网络环境等多个维度,就可以使用户方便地了解应用在各种状态下的性能表现,从而及时发现问题,做出适当的调整,达到优化用户体验的目的。

下图是NAPM平台某个应用的多维分析展示界面

Alt pic

接下来主要给大家分享一下网易NAPM Android端SDK的实现原理。

Android APM基本原理

简单来说,一个APM平台的工作流程大致如下:在各端(移动端、前端、后端)采集性能数据,然后上传到后端进行建模、存储,由平台进行分析、挖掘,最后通过可视化的方式展示给用户。

移动端SDK实际上只是一个数据采集系统,负责收集并上传终端上产生的性能数据,大致可以划分为三个模块,最底层是数据采集模块,负责采集各种性能数据,采集到的数据经过简单的处理之后存储在内存或者数据库中,最上层是数据的消费模块,通常会将采集到的数据上传到后台,供平台存储、分析和展示,同时我们也支持将采集到的性能数据交给用户处理,方便用户挖掘有用信息。

Alt pic

这里我们使用到了数据库,主要是因为存在一些情况,会导致采集到的数据不能实时发送至后台

  • 当网络状态较差,上传失败
  • 当前无可用网络连接,无法上传
  • 当前网络状态不满足上传条件(用户可以设置,比如仅在wifi的状态下上传数据)

因此我们需要将数据进行存储,在合适的时机上传到后台,尽量保证数据的完整。

APM SDK的难点是数据的采集,手动埋点的方式无疑是行不通的,一方面代价太大且容易产生错误,另一方面对于没有源代码的第三方库我们无法直接修改,因而不能满足我们的需求。参考New Relic,我们选择在应用构建期间通过修改字节码的方式来进行代码插桩。

首先我们看一下应用构建的过程:

Alt pic

可以看到,应用中所有的class文件包括引用的第三方库中的class,都会经由dex过程,被转化为一个或者多个dex文件,正因为所有的class文件都会在dex这一步被处理,所以我们选择在这里进行字节码插桩。

javaagent + Instrumentation

dex的过程是在dx程序中进行,而dx程序是由java实现的,这里我们使用到了javaagent技术,它可以使我们在JVM加载class文件前对字节码作出修改,这里简单介绍一下用法,主要分为两步

  1. 实现一个javaagent
  2. 加载javaagent

实现javaagent

javaagent的形式是一个jar包,根据javaagent的不同加载方式,对它的实现也有不同的要求。

如果javaagent是在虚拟机启动之后加载的,我们需要在它的manifest文件中指定Agent-Class属性,它的值是javaagent的实现类,这个实现类需要实现一个agentmain方法

public static void agentmain(String agentArgs, Instrumentation instrumentation) {
    //xxx
}

agentmain会成为javaagent的入口,它会在javaagent被加载时调用。

但是如果javaagent是在JVM启动时通过命令行参数加载的,情况会不太一样,需要在它的manifest文件中指定Premain-Class属性,它的值是javaagent的实现类,这个实现类需要实现一个premain方法。

public static void premain(String agentArgs, Instrumentation instrumentation) {
    //xxx
}

我们知道,一个java程序的入口是main方法,而如果javaagent是在JVM启动时通过命令行参数加载的,虚拟机会在应用的main方法执行之前调用javaagent的premain方法,这应该也是premain方法名字的由来吧。

如果要支持两种加载方式,那么上述的条件需要同时满足。并且如果通过命令行参数在JVM启动时加载,agentmain方法不会再被调用。而在这个时候,应用中的类还没有被加载到虚拟机,所以给我们修改字节码带来了便利,因为一个类被加载之后,修改它的字节码会比较麻烦。

我们看到premain方法的第二个参数是一个Instrumentation的实例,Instrumentation接口有一个方法

void addTransformer(ClassFileTransformer transformer, boolean canRetransform)

它会在虚拟机中注册一个ClassFileTransformer,transformer会在类加载时对类进行处理,ClassFileTransformer接口只定义了一个方法

byte[] transform(ClassLoader loader,
               String className,
               Class<?> classBeingRedefined,
               ProtectionDomain protectionDomain,
               byte[] classfileBuffer)
                 throws IllegalClassFormatException

而这个方法的作用就是修改一个类的字节码,className是这个类的名称,classfileBuffer是这个类原本的字节码,而返回值是修改过后的字节码,如果没有修改,可以直接返回null。

因此,如果我们想在程序运行前改变一个类的字节码,可以在javaagent的premain方法中调用Instrumentation的实例的addTransformer方法,添加一个自定义的ClassFileTransformer。伪代码如下:

//实现一个javaagent,注册自定义的ClassFileTransformer
public class MyJavaAgent { 
    public static void premain(String agentArgs, Instrumentation inst) 
            throws ClassNotFoundException, UnmodifiableClassException { 
        inst.addTransformer(new MyTransformer()); 
    } 
 }

//实现一个 ClassFileTransformer,对xxx.xxx.xxx类的字节码进行修改
public class MyTransformer implements ClassFileTransformer {
    public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
        ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
        if(name.equals("xxx.xxx.xxx")) {
            return changeByteCode(bytes);
        }
        return null;
    }
}

加载javaagent

前边已经提到了javaagent有两种加载方式

1) JVM启动时通过命令行参数加载javaagent

  • manifest中需要指定Premain-Class属性
  • 需要实现premain方法
  • premain方法会在程序的main方法之前执行
  • agentmain方式不会被调用

    通过命令行加载javaagent的形式如下:

    -javaagent:jarpath[=options]
    

    一个示例如下:

    java -javaagent:/path/to/myagent.jar -jar myapp.jar
    

2) JVM启动后动态加载javaagent

  • manifest中需要指定Agent-Class属性
  • 需要实现agentmain方法
  • agentmain方法会在javaagent被加载时执行

    一般运行时加载agent的方法如下:

    String nameOfRunningVM = ManagementFactory.getRuntimeMXBean().getName();
    int p = nameOfRunningVM.indexOf('@');
    String pid = nameOfRunningVM.substring(0, p);
    
    String jarFilePath = "/the/path/to/the/agent/jar";
    
    try {
        VirtualMachine vm = VirtualMachine.attach(pid);
        vm.loadAgent(jarFilePath);
        vm.detach();
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
    

具体使用细节可参考VirtualMachine介绍http://docs.oracle.com/javase/7/docs/jdk/api/attach/spec/com/sun/tools/attach/VirtualMachine.html

借助javaagent,我们可以将代码插桩的工作分为两个步骤:首先是获取到应用中所有的字节码,然后是对应用的字节码进行修改。

获取应用字节码

首先从要解决的问题出发,上边提到我们会在dex的这一步去获取字节码,通过查看dx程序的代码,我们发现,在dex的过程中所有的class文件会经由com.android.dx.command.dexer.MainprocessClass()方法进行处理,processClass()的代码如下:

/**
 * Processes one classfile.
 *
 * @param name {@code non-null;} name of the file, clipped such that it
 * <i>should</i> correspond to the name of the class it contains
 * @param bytes {@code non-null;} contents of the file
 * @return whether processing was successful
 */
private boolean processClass(String name, byte[] bytes) {

    if (! args.coreLibrary) {
        checkClassName(name);
    }

    try {
        new DirectClassFileConsumer(name, bytes, null).call(
                new ClassParserTask(name, bytes).call());
    } catch(Exception ex) {
        throw new RuntimeException("Exception parsing classes", ex);
    }

    return true;
}

第一个参数是应用中一个类的名字,第二个参数就是这个类的字节码了,应用中所有的类,都会经过这个函数进行处理。

所以我们打算修改com.android.dx.command.dexer.MainprocessClass()方法,从而获取到应用中的字节码,那么现在的问题就变成了如何修改com.android.dx.command.dexer.MainprocessClass()方法。

掌握了javaagent,想要修改dx程序中com.android.dx.command.dexer.Main的字节码就变得比较容易了,我们需要实现一个javaagent,在其中注册一个ClassFileTransformer,在ClassFileTransformer的transform()方法中对com.android.dx.command.dexer.Main的字节码进行修改,最后在dx程序启动时将这个javaagent加载进去就好了。

//实现一个 ClassFileTransformer,对com.android.dx.command.dexer.Main类的字节码进行修改
public class MainTransformer implements ClassFileTransformer {
    public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
        ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
        if(name.equals("com/android/dx/command/dexer/Main")) {
            return changeMainClassByteCode(bytes);
        }
        return null;
    }
}

byte[] changeMainByteCode(byte[] bytes) {
    //修改Main的 processClass() 方法
    //返回修改后Main的字节码
}

如果你是通过命令行来手动构建应用的,到这里已经可以用上边的方式获取到应用中的字节码了,然而大多数人在开发Android的时候,并不会通过命令行去手动构建,而是通过使用一些构建工具,来完成自动化构建,而dx程序则是由构建工具启动的,所以我们面临的问题就是如何将javaagent加载到dx进程。

我们目前支持了ant构建和gradle构建,通过查看ant和gradle的代码,我们发现最终它们都会通过java.lang.ProcessBuilderstart()方法来启动dx进程。

通过查看java.lang.ProcessBuilder的代码,我们发现它有一个成员

private List<String> command;

它是用来保存的是启动目标进程的命令和参数,我们需要做的就是在调用start()方法启动dx进程时,将加载javaagent的参数(-javaagent:jarpath[=options])添加到command中。

这里我们仍然使用javaagent来完成这个工作,我们需要实现另外一个javaagent,在其中注册一个另一个ClassFileTransformer,在它的transform方法中对java.lang.ProcessBuilder的字节码进行修改。

//实现一个 ClassFileTransformer,对com.android.dx.command.dexer.Main类的字节码进行修改
public class ProcessBuilderTransformer implements ClassFileTransformer {
    public byte[] transform(ClassLoader classLoader, String className, Class<?> clazz,
        ProtectionDomain protectionDomain, byte[] bytes) throws IllegalClassFormatException {
        if(name.equals("java/lang/ProcessBuilder")) {
            return changeProcessBuilderClassByteCode(bytes);
        }
        return null;
    }
}

byte[] changeProcessBuilderClassByteCode(byte[] bytes) {
    //修改ProcessBuilder的 start() 方法
    //返回修改后ProcessBuilder的字节码
}

那么最终问题就变成了如何把这个javaagent加载到ant进程和gradle进程。

它们对应到了javaagent的两种加载方式

  • ant构建-JVM启动时加载

    export ANT_OPTS="-javaagent:/path/to/agent.jar"(mac os环境,windows不太一样)
    

    在ant构建前进行上述配置,可以在启动ant时加载指定的javaagent,这里使用的是在JVM启动时通过命令行参数加载javaagent的方式。

  • gradle构建 -JVM启动后加载

    我们会编写一个gradle插件来完成javaagent的加载,当我们的插件被加载时,gradle进程已经运行起来了,因此只能通过动态的方式加载javaagent。

因此,获取字节码的流程,大致如下图所示:
Alt pic

这个过程中主要使用了两个javaagent,一个用来修改ProcessBuilder类,另一个用来修改Main类,涉及到的进程是ant构建进程或者gradle构建进程,以及由它们启动的dx进程。

对于gradle构建方式,需要注意一点,gradle plugin 在2.1.0之后的版本,支持dx in-process,它使得dx的过程可以直接在当前的gradle进程中执行,而不需要额外启动一个dx进程,从而缩短应用构建的时间。如果你在使用Android Studio构建应用的时候看到To run dex in process, the Gradle daemon needs a larger heap. It currently has 910 MB这样的一句话,它就是指导用户通过配置gradle daemon进程的堆大小来开启dx in-process特性的。

而这个新的特性,会给我们设置javaagent带来麻烦,不启动dx进程使得我们无法对dx进程设置javaagent,而在gradle进程中动态加载javaagent时,com.android.dx.command.dexer.Main类早已经加载过了,所以通过javaagent方式来获取字节码会变得十分困难。

幸运的是,gradle plugin 在1.5.0之后,提供了一个Transform API,它允许第三方插件操作编译后的class文件,而修改的时机正是在将这些字节码转换为dex文件之前,这里就不在展开讲解了,感兴趣的同学可以参考下这篇文章http://blog.csdn.net/sbsujjbcy/article/details/50839263

修改应用字节码

通过javaagent修改com.android.dx.command.dexer.Mainjava.lang.ProcessBuilder,以及最终修改应用的字节码进行插桩,都需要对.class文件的格式以及java虚拟机有比较深入的了解,另外需要使用字节码操作工具来帮助我们对字节码进行改造,这里不详细讲解,只是推荐一些有用的的字节码操作框架和工具,后边可能会有同事做相关的分享。

  • ASM是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。

  • Javassist是一个开源的分析、编辑和创建Java字节码的类库,它提供了源码级别的API以及字节码级别的API,源码级别的API,直接使用java编码的形式,而不需要深入了解虚拟机指令,就能动态改变类的结构或者动态生成类。

  • Bytecode Outline plugin for Eclipse是一个非常有用的eclipse 插件,可以查看当前正在编辑的java文件或者class文件的字节码。

  • 如果需要逆向APK,查看字节码修改的效果,除了dex2jar外,再给大家推荐一个google的逆向工具enjarify

小结

本文重点介绍了使用javaagent在应用打包过程中修改com.android.dx.command.dexer.Mainjava.lang.ProcessBuilder的字节码,从而获取到应用的字节码,进行插桩的基本原理,并没有涉及so hook相关的原理,以后有机会的话会再做一次分享。